Migrate pipeline from CircleCI to GitHub Actions#218
Conversation
phelma
left a comment
There was a problem hiding this comment.
Code Review: #218 - Migrate pipeline from CircleCI to GitHub Actions
Verdict: COMMENT
This is a careful, plan-conformant CircleCI→GHA cutover: the workflow YAML, Rakefile tasks, gemspec/lockfile, README and decommission deletions all match the Variant A family plan, and the security-sensitive parts (untrusted-input handling via env vars, fork/dependabot guards, environment-gated release, git-crypt ciphertext guard, per-job timeouts and concurrency) are sound. One real in-scope defect surfaced under verification: both workflows' Test jobs run ./go spec, which is a no-op — no spec rake task exists, so rake resolves it against the existing spec/ directory and exits green having run zero examples (./go test:unit runs the real 187-example suite). The test gate therefore passes without testing anything, and it gates dependabot auto-merge and release. Everything else is either plan-conformant or a documented-deliberate decision.
Cross-Cutting Themes
- Empty test gate (correctness; reviewer-verified) — the Test job runs
./go spec(a no-op file task) instead of./go test:unit(187 examples). Reported by the correctness lens as a hard failure; direct verification shows it does not fail — it silently runs no tests and reports success. This is the single actionable finding.
Tradeoff Analysis
- Literal parity vs effective CI: plan §4.2 says "use the repo's own check/test task names," and the deleted
test.shdid invoke./go spec— sospecis faithful parity. But §2.3 assertstest.sh → ./go test:unitand §5 verifies the suite viatest:unit. A test gate that runs zero tests (inherited from the old, equally-empty pipeline) is almost certainly not the intent. Recommend switching both Test steps to./go test:unit.
Strengths
- ✅ Untrusted PR values passed via
env:and referenced as$VARinrun:— correct anti-injection pattern; no${{ }}interpolation into shell. - ✅
prerelease(secret-bearing) job guarded to same-repo non-dependabot PRs via the immutablepull_request.user.login; usespull_request, notpull_request_target. - ✅ Provisioning fails fast on a git-crypt-locked clone (checks
\x00GITCRYPT) rather than uploading ciphertext; token resolution fails fast. - ✅
prerelease:publishrestoresversion.rband removes the built gem in anensureblock. - ✅ Every job carries
timeout-minutes; prerelease/release share amainconcurrency group withqueue: maxto serialise version-racing releases. - ✅ Alphabetical require/dev-dependency lists; self-contained comments (no migration/plan/D-number references); PLATFORMS keep
ruby+x86_64-linux, no darwin.
General Findings
- 🟡 Correctness/Safety: Test job runs
./go spec(no-op) — see inline comments. Verified:test:unit= 187 examples;spec= 0 examples, exit 0. - 🔵 Standards:
.rubocop.ymladdsdefine_repository_taskstoMetrics/BlockLength.Excluded— outside the plan's enumerated file list (see inline). Justified and harmless, but confirm intended.
Plan Concerns (documented-deliberate — not defects; recorded so a human can revisit the plan)
- 🔵 Security:
actions/checkout@v4andasdf_install@v1pinned to mutable tags in secret-bearing jobs — plan §1/D6 accepts tracking the moving@v1tag;@v4is prescribed verbatim. SHA-pinning is post-migration hardening. - 🔵 Security: dependabot auto-merge accepts any update type that passes checks — documented D3.
- 🔵 Safety: main
prereleasepublishes to RubyGems beforegit pushwith nogit pull; recovery isgem yank— documented publish-before-push parity (D5, §1). - 🔵 Safety: a
releasejob pending environment approval holds themainconcurrency slot, stalling later prereleases — documented tradeoff (§4.2). - 🔵 Code Quality:
prerelease:publish'ssub(pattern, "\\1#{version}\\3")backreference-interpolation, hard-coded gem filename, and duplicatedgem buildall sit on code prescribed near-verbatim by plan §4.4 step 8 — observations against the prescribed shape, not defects.
Review generated by /accelerator:review-pr
| - name: Install tools | ||
| uses: infrablocks/github-actions/asdf_install@v1 | ||
| - name: Test | ||
| run: ./go spec |
There was a problem hiding this comment.
🟡 Correctness (major, reviewer-verified)
The Test job runs ./go spec (= bundle exec rake spec). This repo defines no spec task — the unit suite is test:unit (RSpec::Core::RakeTask.new(:unit) under namespace :test). Rake instead resolves spec against the existing spec/ directory as an up-to-date file task, so the step exits 0 having run zero examples.
Verified locally: bundle exec rake test:unit → "187 examples, 0 failures"; bundle exec rake spec → no examples run, exit 0.
Impact: The Test job is green but tests nothing. It gates prerelease and release (needs: [check, test]), so untested code publishes to RubyGems on every push to main. (This matches the deleted test.sh, which also ran ./go spec — an inherited empty gate — but plan §2.3/§5 intend test:unit.)
Suggestion: Change to run: ./go test:unit.
| - name: Install tools | ||
| uses: infrablocks/github-actions/asdf_install@v1 | ||
| - name: Test | ||
| run: ./go spec |
There was a problem hiding this comment.
🟡 Correctness (major, reviewer-verified)
Same as main.yaml: the PR Test job runs ./go spec, which resolves to a no-op file task (no spec rake task exists; spec/ is a directory) and runs zero examples while reporting success. The real suite is ./go test:unit (187 examples).
Impact: On PRs the test gate passes without testing. Both prerelease and merge-pull-request needs: [check, test, build], so dependabot auto-merge and the PR-CI prerelease publish are gated on a test job that runs nothing.
Suggestion: Change to run: ./go test:unit.
| - context | ||
| - shared_examples | ||
| - it | ||
| - define_repository_tasks |
There was a problem hiding this comment.
🔵 Standards (suggestion)
This adds define_repository_tasks to Metrics/BlockLength.Excluded. The family plan enumerates the cutover's file changes and says "Do not restructure anything else in the Rakefile … keep it clean"; it does not authorise a .rubocop.yml change. The RakeGithub block genuinely grew past the BlockLength threshold once the mandated token/passphrase guard was inlined, so an exclusion is a defensible way to keep ./go library:check green, and it is stylistically consistent with the existing DSL exclusions.
Impact: A config change beyond the plan's prescribed scope; harmless here but worth confirming it was intended rather than accidental drift.
Suggestion: Confirm the exclusion is intended; note it as a deliberate deviation from the plan's file list.
phelma
left a comment
There was a problem hiding this comment.
Code Review: #218 - Migrate pipeline from CircleCI to GitHub Actions
Verdict: COMMENT
This is a clean, plan-conformant Variant A cutover. Across correctness, security, safety, standards and code-quality lenses the diff faithfully re-expresses the CircleCI skeleton as GitHub Actions, with unusually careful security hygiene (attacker-influenced values passed via env: not ${{ }}, --match-head-commit on the auto-merge, secret-bearing jobs fenced to same-repo human PRs, fail-fast provisioning guards). No in-scope defects were found. Almost every finding raised by the lenses challenges a decision the family plan or PR description documents as deliberate — these are recorded below as plan concerns for a human to weigh against the plan, and do not block the diff. Two lens findings were disproven and are noted as false positives.
False Positives (disproven — not counted)
- 🔴→✅
queue: maxis "invalid GitHub Actions syntax" (flagged by correctness ×2 and safety). Theconcurrency: { queue: max }key on main.yaml'sprerelease/releasejobs was flagged as a non-existent option that would fail to parse or drop queued runs. This is a knowledge-cutoff artefact:queue: maxis a real GitHub Actions concurrency option that reached GA on 2026-05-07 (https://github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues/), after the reviewers' training cutoff. The plan relies on it deliberately. Not a defect.
Plan Concerns (documented-deliberate — non-blocking)
Each targets something the plan or PR description records as intended, so none block this plan-conformant diff.
- 🟡 Same-repo PR
prereleasejob exposes the full publishing secret chain to PR-branch code (security) — documented D8; no environment gate. Worth revisiting whether the PR publish should sit behind a reviewed environment or a push-only RubyGems key. - 🟡 Dependabot auto-merge may run with a read-only
GITHUB_TOKEN(correctness) — worth a human verifying the job-levelpermissionsreliably elevatessecrets.GITHUB_TOKENon thepull_requestevent; mechanism is exactly what D3 prescribes. - 🔵 Dependabot auto-merge accepts any update type (security) — §1 parity / D3.
- 🔵
./go releasepublishes to RubyGems before pushing the version-bump commit/tag (safety) — §1 parity / D5 inherited hazard. - 🔵 Committed encrypted GPG key concentrates release trust in one secret (security) — D1; worth rotating the passphrase/GPG key at CircleCI decommission.
- 🔵 PR-CI prereleases accumulate irreversibly on public RubyGems (safety) — accepted in D8.
- 🔵
define_repository_tasksBlockLength rubocop exemption diverges from the sibling helper-extraction convention (standards) — the plan prescribes the inline shape verbatim (§4.4 step 3). - 🔵
gh auth tokenfallback only rescuesErrno::ENOENT(code-quality) — plan-verbatim §4.4 step 3. - 🔵
prerelease:publishString#subreplacement assumes numeric args (correctness) — safe under current data flow; plan-verbatim §4.4 step 8. - 🔵
gem buildduplicated betweenlibrary:buildandprerelease:publish(code-quality) — both plan-verbatim. - 🔵 Slack channel IDs are opaque repeated literals (code-quality) — plan-verbatim §4.4 step 4.
Strengths
- ✅ Systematic script-injection avoidance: only the trusted
job.statusenum is interpolated intorun:; PR title/URL/number and run metadata flow throughenv:. - ✅
gh pr merge --match-head-commit "$HEAD_SHA"closes the merge TOCTOU. - ✅
prerelease:publishrestoresversion.rband removes the built gem in anensureblock. - ✅ Provisioning fails fast on a missing passphrase and refuses to upload git-crypt ciphertext as a secret.
- ✅ Least-privilege token model;
releasegated behind a reviewedenvironment. - ✅ Workflow YAML matches the established sibling migration; requires/dependencies stay alphabetised.
Plan-conformance (independent §4 check)
Both directions verified. All §4 changes are present and correct; the release job's version:bump[minor] correctly mirrors the repo's old release.sh (a parameterisation point, not the template's [patch]), and documentation:update is correctly omitted. Nothing changed beyond the plan except the benign .rubocop.yml BlockLength exemption. No plan violations found.
Review generated by /accelerator:review-pr
| ENCRYPTION_PASSPHRASE: ${{ secrets.ENCRYPTION_PASSPHRASE }} | ||
| - name: Configure RubyGems credentials | ||
| run: ./scripts/ci/common/configure-rubygems.sh | ||
| - name: Publish prerelease |
There was a problem hiding this comment.
🟡 Security — plan concern (documented-deliberate, D8; not blocking)
This pull_request-triggered job checks out the PR branch and runs that branch's own scripts with ENCRYPTION_PASSPHRASE in scope, which decrypts the committed GPG key → unlocks git-crypt → yields the RubyGems push credentials. The if guard excludes forks and Dependabot but not a rogue/compromised same-repo contributor branch, and unlike release there is no environment/reviewer gate. The plan documents this as intended (pre-merge proof of the publish path). Worth a human deciding whether to gate the PR publish behind a reviewed environment or a push-only RubyGems key that rotates independently of the git-crypt passphrase.
| needs: [check, test, build] | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| if: github.event.pull_request.user.login == 'dependabot[bot]' |
There was a problem hiding this comment.
🔵 Security — plan concern (§1 parity, D3; not blocking)
The job auto-merges any Dependabot PR that passes checks, with no update-type restriction. A poisoned upstream release that still passes the unit/build checks would merge automatically (shipping with the next human-triggered release). Explicitly accepted as parity; post-migration hardening may add a steps.metadata.update-type allow-list.
| # --match-head-commit fails the merge if a commit landed after checks passed | ||
| # [skip ci] stops the merge commit triggering a release build | ||
| # PR title via env, never interpolated | ||
| run: gh pr merge --merge --match-head-commit "$HEAD_SHA" "$PR_URL" --subject "$PR_TITLE [skip ci]" |
There was a problem hiding this comment.
🟡 Correctness — plan concern (implements D3; flagged for human verification, not blocking)
For pull_request runs triggered by Dependabot, GitHub historically grants a read-only GITHUB_TOKEN and withholds secrets. This job relies on the job-level permissions: { contents: write, pull-requests: write } to elevate secrets.GITHUB_TOKEN for the gh pr merge call. Worth confirming against current GitHub Dependabot token behaviour that this reliably elevates on the pull_request event — if not, Dependabot PRs would silently never auto-merge (a functional regression). The mechanism itself is exactly what D3 prescribes.
| - name: Set CI git author | ||
| run: ./go repository:set_ci_author | ||
| - name: Bump version | ||
| run: ./go "version:bump[pre]" |
There was a problem hiding this comment.
🔵 Safety — plan concern (§1 parity, D5 inherited hazard; not blocking)
./go release pushes the gem to RubyGems before the subsequent git push / git push --tags steps. If the push fails (e.g. main advanced during the run) the version is already published and irreversible, but no matching commit/tag exists in the repo, and the next run collides on the version. Inherited from the untouched ./go release logic and explicitly flagged as deliberate; a fleet-wide reorder is post-migration work.
| - context | ||
| - shared_examples | ||
| - it | ||
| - define_repository_tasks |
There was a problem hiding this comment.
🔵 Standards — plan concern (consequence of plan-verbatim inline code; not blocking)
This adds define_repository_tasks to Metrics/BlockLength AllowedMethods because the Rakefile inlines the token/passphrase resolution inside the RakeGithub.define_repository_tasks block (the plan's verbatim §4.4 step-3 code). The sibling rake_dependencies migration instead extracts top-level helper methods and needs no exemption. Following the plan's prescribed inline shape is the deliberate choice; worth a human aligning the family on one convention.
Cutover to GitHub Actions per the Variant A family plan (gem pilot).
Includes decommission — merging this PR completes the repo's migration.
releaseenvironment gate.github/rake_slack; dependabot auto-merge jobrake_githubsecrets/environments;rake_circle_cidropped.circleci/,scripts/ci/, the CI SSH deploykey pair and its
keys:deploy/deploy_keysprovisioning, and the storedCircleCI/GitHub API credentials (
config/secrets/{circle_ci,github}/)Deliberate decisions (not defects)
This cutover reproduces the CircleCI pipeline's behaviour, warts included;
fixing inherited hazards is post-migration work. In particular:
./go releasepublishes to RubyGems before the version-bump commit ispushed — pre-existing ordering inside the untouched release logic.
mainwith no approval gate; onlyfull releases are gated (
environment: release).merge does not trigger a release build — on CircleCI the merge commit
carried
[skip ci], so this matches. Updates ship with the nexthuman-triggered release.
releasejob pullsmainat approval time, so a delayed approvalpublishes main as it stands then, not the SHA this run tested — parity with
the old
release.sh(which also pulled;prerelease.shdid not, so theprerelease job has no pull).
asdf_install@v1is our own action (infrablocks/github-actions); we arehappy tracking its major version tag.
build system (
./go/rake) and CI stays lean — it just triggers tasks andsupplies secrets/context.
Gemfile.lockcarries transitive major bumps — the unavoidable resolutionof the targeted
bundle lock --update, not scope creep.autocorrects existing code (e.g.
Style/ArgumentsForwarding) — requiredby the
library:checkverification gate, not drive-by refactoring.pipeline:prepare) authenticates with the operator's ambientghlogin (GITHUB_TOKENfallback) instead of a stored PAT — a deliberateparity deviation; the stored token in
config/secrets/github/config.yamlis deleted with the rest of the CircleCI-era credentials.
PR-CI prerelease publish (deliberate, permanent)
pr.yamlhas aprereleasejob that publishes a namespaced pre-release ofthis gem to RubyGems from the PR branch — a permanent CI feature, not
migration-only. This is a deliberate deviation from CircleCI (which published
nothing pre-merge): it proves the publish path before merge instead of
discovering it broken on
main. The version is<committed-version>.pr<PR>.<run>.<attempt>(via the newprerelease:publishRakefile task), so it can never collide with
main'sversion:bump[pre]sequence; the task builds the gem and pushes it straight to RubyGems, then
restores
version.rb, so nothing is committed, tagged, or pushed(
gem releaseis not used — it aborts on the uncommitted version rewrite).The job is skipped for fork
and Dependabot PRs (they hold no secrets), and
merge-pull-requestdoes notdepend on it. PR pre-release versions accumulate permanently on RubyGems —
accepted.
Do not merge manually — the pipeline merges once checks are green.
Disabling the CircleCI project and deleting the
CircleCIdeploy key aredeferred to the end-of-migration sweep.
🏭 This PR was opened by Foundry, Atomic's AI software development
factory. Implementation, review, and fixes are performed by AI agents;
merges happen automatically once the review and checks gates pass.
This task migrates a Ruby gem's CI from CircleCI to GitHub Actions.
migratemigrate-gem2026-07-22T15-58-21-187Zatomic-foundry-pr · foundry-pipeline: migrate · foundry-task: migrate-gem · foundry-run: 2026-07-22T15-58-21-187Z